package com.vaadin.thomas.contenttool.data;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import org.neo4j.graphdb.Direction;
import org.neo4j.graphdb.GraphDatabaseService;
import org.neo4j.graphdb.Node;
import org.neo4j.graphdb.Relationship;
import org.neo4j.graphdb.Transaction;
import org.neo4j.kernel.EmbeddedGraphDatabase;

import com.vaadin.thomas.contenttool.Config;
import com.vaadin.thomas.contenttool.exceptions.ContentModelException;

/**
 * Singleton gateway to the underlying Neo4j data store.
 * 
 * @author Thomas Mattsson / Vaadin Ltd.
 */
public class Model {

	static EmbeddedGraphDatabase db;
	static Config conf;

	private static Model instance;

	/**
	 * Must be called before {@link #get()}. Subsequent calls to this method do
	 * nothing.
	 */
	public static synchronized void initialize(Config c) {
		if (instance == null) {
			instance = new Model(c);
		}
	}

	/**
	 * Returns the singleton, thread-safe {@link Model} instance.
	 * <p>
	 * Make sure you call {@link #initialize(Config)} before this method!
	 */
	public static Model get() {
		if (instance == null) {
			throw new ContentModelException("Model not initialized");
		}
		return instance;
	}

	public static void destroy() {
		if (db != null) {
			db.shutdown();
		}
	}

	private volatile Version production;
	private volatile Version preview;

	/**
	 * Singleton-pattern private constructor.
	 */
	private Model(Config c) {

		conf = c;

		Map<String, String> config = new HashMap<String, String>();
		db = new EmbeddedGraphDatabase(conf.storageLocation(), config);
		registerShutdownHook(db);

		assertMinimalGrafReqs();
	}

	/**
	 * Assert that we have a Tag node, and that we have at least one
	 * {@link Version}.
	 * <p>
	 * 
	 * If these conditions aren't met, try to fix them.
	 */
	private void assertMinimalGrafReqs() {

		Relationship tags = db.getReferenceNode().getSingleRelationship(
				References.ROOT_TO_TAG, Direction.OUTGOING);

		if (getVersions().size() > 1 && tags != null) {
			return;
		}

		Transaction tx = db.beginTx();
		try {
			Version preview = new Version(Model.db.createNode());
			preview.node.setProperty(Version.PROP_VERSION,
					Version.VERSION_PREVIEW);
			db.getReferenceNode().createRelationshipTo(preview.node,
					References.ROOT_TO_VERSION);

			Node tag = Model.db.createNode();
			db.getReferenceNode().createRelationshipTo(tag,
					References.ROOT_TO_TAG);

			tx.success();
		} catch (RuntimeException e) {
			tx.failure();
			throw e;
		} finally {
			tx.finish();
		}

		commitPreview(false, new HashSet<Value>(), "system", "");

	}

	public List<Version> getVersions() {

		List<Version> versions = new LinkedList<Version>();
		for (Relationship r : db.getReferenceNode().getRelationships(
				References.ROOT_TO_VERSION)) {
			Node v = r.getEndNode();
			versions.add(new Version(v));
		}
		Collections.sort(versions);
		return versions;
	}

	/**
	 * Get all {@link Tag}s registered to the data store.
	 */
	public List<Tag> getTags() {

		List<Tag> versions = new LinkedList<Tag>();
		Node tags = db
				.getReferenceNode()
				.getSingleRelationship(References.ROOT_TO_TAG,
						Direction.OUTGOING).getEndNode();
		for (Relationship r : tags.getRelationships(References.TAG_TO_TAGS)) {
			Node t = r.getEndNode();
			versions.add(new Tag(t));
		}
		Collections.sort(versions);
		return versions;
	}

	public List<Value> getAllVersions(String identifier) {

		Value v = getPreview().getValue(identifier);
		if (v == null) {
			return null;
		}

		List<Value> versions = new LinkedList<Value>();
		while (v != null) {
			versions.add(v);
			v = v.getPreviousVersion();
		}
		return versions;
	}

	public Version getPreview() {

		if (preview != null) {
			return preview;
		}

		for (Relationship r : db.getReferenceNode().getRelationships(
				References.ROOT_TO_VERSION)) {
			Node v = r.getEndNode();

			if (NodeUtil.getInt(v, Version.PROP_VERSION) == Version.VERSION_PREVIEW) {
				synchronized (this) {
					preview = new Version(v);
				}
				return preview;
			}
		}

		throw new ContentModelException("no preview found!");
	}

	/**
	 * Creates a new {@link Version} of the given {@link Value}s in the Preview
	 * Version and creates a new Preview.
	 * 
	 * @param copyUnstoredToNew
	 *            If those values that are in the Preview but not in
	 *            <code>valuesToStore</code> should be moved to the new Preview,
	 *            or discarded.
	 * @param valuesToStore
	 *            A collection of all the {@link Value}s that should be a part
	 *            of the new version
	 * @param comitter
	 * @param commitMessage
	 */
	public synchronized void commitPreview(boolean copyUnstoredToNew,
			Collection<Value> valuesToStore, String comitter,
			String commitMessage) {

		// dump();

		Version oldPreview = getPreview();
		Transaction tx = db.beginTx();

		try {
			Version newPreview = new Version(db.createNode());
			newPreview.setVersionProperty(Version.PROP_VERSION,
					Version.VERSION_PREVIEW);
			db.getReferenceNode().createRelationshipTo(newPreview.node,
					References.ROOT_TO_VERSION);

			oldPreview.node.createRelationshipTo(newPreview.node,
					References.VERSION_TO_NEWER);

			oldPreview.setVersionProperty(Version.PROP_MESSAGE, commitMessage);
			oldPreview.setVersionProperty(Version.PROP_COMMITTED,
					new Date().getTime());
			oldPreview.setVersionProperty(Version.PROP_COMMITTER, comitter);
			oldPreview.setVersionProperty(Version.PROP_VERSION, getVersions()
					.size() - 1);

			for (Relationship r : oldPreview.node.getRelationships(
					Direction.OUTGOING, References.VERSION_TO_VALUES)) {

				if (!valuesToStore.contains(new Version(r.getEndNode()))) {

					// not part of version

					if (copyUnstoredToNew) {
						// move to new preview
						newPreview.node.createRelationshipTo(r.getEndNode(),
								References.VERSION_TO_VALUES);
						r.delete();

					} else {
						// remove from graph
						Value v = new Value(r.getEndNode());
						deleteValueInternal(v);
					}
				} else {

					// part of version; keep all references intact.
				}
			}

			tx.success();

			preview = newPreview;
			production = oldPreview;

		} catch (RuntimeException e) {
			tx.failure();
			throw e;
		} finally {
			tx.finish();
		}

		// dump();
	}

	/**
	 * Discards all {@link Value}s in the current Preview {@link Version}.
	 */
	public synchronized void discardPreview() {

		Version preview = getPreview();
		Transaction tx = db.beginTx();

		try {

			for (Relationship r : preview.node.getRelationships(
					Direction.OUTGOING, References.VERSION_TO_VALUES)) {

				// remove from graph
				Value v = new Value(r.getEndNode());
				deleteValueInternal(v);
			}

			tx.success();

		} catch (RuntimeException e) {
			tx.failure();
			throw e;
		} finally {
			tx.finish();
		}
	}

	/**
	 * Deletes a {@link Value} completely from the graph. Must be called inside
	 * a {@link Transaction}.
	 * 
	 * @param v
	 *            the {@link Value} to delete.
	 */
	private void deleteValueInternal(Value v) {
		for (List<Localization> list : v.getLocalizations().values()) {
			for (Localization l : list) {
				for (Relationship rel : l.node.getRelationships()) {
					rel.delete();
				}
				l.node.delete();
			}
		}

		for (Relationship rel : v.node.getRelationships()) {
			rel.delete();
		}

		v.node.delete();
	}

	private static void registerShutdownHook(final GraphDatabaseService graphDb) {
		// Registers a shutdown hook for the Neo4j instance so that it
		// shuts down nicely when the VM exits (even if you "Ctrl-C" the
		// running example before it's completed)
		Runtime.getRuntime().addShutdownHook(new Thread() {
			@Override
			public void run() {
				if (graphDb != null) {
					graphDb.shutdown();
				}
			}
		});
	}

	/**
	 * Adds a new Value without {@link Localization}s into the Preview
	 * {@link Version} if it doesn't already exist.
	 */
	public Value addValue(String identifier) {
		Version preview = getPreview();
		Value v = preview.getValuesInThis().get(identifier);

		if (v != null) {
			return v;
		}

		Transaction tx = db.beginTx();
		try {
			preview.addValue(Value.create(identifier));
			tx.success();
		} catch (RuntimeException e) {
			tx.failure();
		} finally {
			tx.finish();
		}

		return preview.getValue(identifier);
	}

	/**
	 * Sets a {@link Value} in the store with the given data. Overwrites any
	 * previous matching values for the identifier-locale-start combination.
	 * 
	 * @param identifier
	 * @param loc
	 * @param value
	 */
	public void setValue(String identifier, Locale loc, String value, Date start) {
		Version preview = getPreview();
		Value v = preview.getValuesInThis().get(identifier);

		Transaction tx = db.beginTx();
		try {

			if (v == null) {
				preview.addValue(Value.create(identifier,
						Localization.create(loc, value, start)));
			} else if (!v.getLocalizations().containsKey(loc)) {
				v.addLocalization(Localization.create(loc, value, start));
			} else {

				Localization existing = null;
				for (Localization l : v.getLocalizations().get(loc)) {
					if (equalsToMinuteOrBothNull(start, l.getStartDate())) {
						existing = l;
						break;
					}
				}

				if (existing == null) {
					v.addLocalization(Localization.create(loc, value, start));
				} else {
					existing.setValue(value);
				}
			}

			tx.success();
		} catch (RuntimeException e) {
			tx.failure();
		} finally {
			tx.finish();
		}
	}

	/**
	 * @see #setValue(String, Locale, String, Date)
	 */
	public void setValue(String identifier, Locale loc, String value) {
		setValue(identifier, loc, value, null);
	}

	private boolean equalsToMinuteOrBothNull(Date first, Date second) {
		if (first == null && second == null) {
			return true;
		}
		if (first == null || second == null) {
			return false;
		}

		Calendar c1 = new GregorianCalendar();
		c1.setTime(first);
		Calendar c2 = new GregorianCalendar();
		c2.setTime(second);

		if (c1.get(Calendar.YEAR) != c2.get(Calendar.YEAR)) {
			return false;
		}
		if (c1.get(Calendar.MONTH) != c2.get(Calendar.MONTH)) {
			return false;
		}
		if (c1.get(Calendar.DAY_OF_MONTH) != c2.get(Calendar.DAY_OF_MONTH)) {
			return false;
		}

		if (c1.get(Calendar.HOUR_OF_DAY) != c2.get(Calendar.HOUR_OF_DAY)) {
			return false;
		}
		if (c1.get(Calendar.MINUTE) != c2.get(Calendar.MINUTE)) {
			return false;
		}
		return true;
	}

	/**
	 * Dumps complete graph to SysOut.
	 */
	public void dump() {
		System.out.println("\n\n==== starting db dump ====");

		int i = 0;
		for (@SuppressWarnings("unused")
		Node n : db.getAllNodes()) {
			i++;
		}
		System.out.println("Contains " + i + " nodes");

		DateFormat df = new SimpleDateFormat("dd.MM.yyyy HH:mm");

		for (Relationship r : db.getReferenceNode().getRelationships(
				Direction.OUTGOING, References.ROOT_TO_VERSION)) {
			Version v = new Version(r.getEndNode());
			System.out.println("  Version " + v.getVersionNumber());

			for (Relationship rel : v.node.getRelationships(Direction.OUTGOING,
					References.VERSION_TO_VALUES)) {
				Value val = new Value(rel.getEndNode());
				System.out.println("    Value " + val.getId()
						+ (val.isDeleted() ? ", deleted" : ""));
				System.out.print("Tags: ");
				for (Tag t : val.getTags()) {
					System.out.print(t.getValue() + ", ");
				}
				System.out.print("\n");

				for (List<Localization> list : val.getLocalizations().values()) {
					for (Localization l : list) {

						String validFrom = "";
						if (l.getStartDate() != null) {
							validFrom = ", valid from: "
									+ df.format(l.getStartDate());
						}
						System.out.println("      "
								+ l.getLanguage().toString() + ": "
								+ l.getValue() + validFrom);
					}
				}
			}
		}

		Relationship r = db.getReferenceNode().getSingleRelationship(
				References.ROOT_TO_TAG, Direction.OUTGOING);
		if (r != null) {
			System.out.println("All tags in system:");
			for (Relationship tag : r.getEndNode().getRelationships(
					References.TAG_TO_TAGS)) {
				System.out.print(new Tag(tag.getEndNode()).getValue() + ", ");
			}
			System.out.println();
		}
	}

	/**
	 * Completely discards the given {@link Localization}, if it is connected to
	 * the Preview {@link Version}.
	 * 
	 * @param data
	 */
	public void discardLocalization(Localization data) {

		if (!isPartOfPreview(data)) {
			throw new ContentModelException("Localization not part of Preview!");
		}

		Transaction tx = db.beginTx();

		try {
			for (Relationship r : data.node.getRelationships()) {
				r.delete();
			}
			data.node.delete();
			tx.success();
		} catch (RuntimeException e) {
			tx.failure();
			throw e;
		} finally {
			tx.finish();
		}
	}

	public Version getProduction() {

		if (production != null) {
			return production;
		}

		Node root = Model.db.getReferenceNode();

		int maxVersion = -1;
		Version prod = null;
		for (Relationship r : root.getRelationships(References.ROOT_TO_VERSION)) {
			Node version = r.getEndNode();
			int nodeVersion = NodeUtil.getInt(version, Version.PROP_VERSION);
			if (nodeVersion != Version.VERSION_PREVIEW
					&& nodeVersion > maxVersion) {
				maxVersion = nodeVersion;
				prod = new Version(version);
			}
		}

		synchronized (this) {
			production = prod;
		}
		return prod;
	}

	public boolean isPartOfPreview(Value v) {

		Relationship toVersion = v.node.getSingleRelationship(
				References.VERSION_TO_VALUES, Direction.INCOMING);
		if (toVersion == null) {
			return false;
		}

		if (!new Version(toVersion.getStartNode()).isPreview()) {
			return false;
		}

		return true;
	}

	public boolean isPartOfPreview(Localization l) {
		if (l == null) {
			return false;
		}

		Relationship toValue = l.node.getSingleRelationship(
				References.VALUE_TO_LOC, Direction.INCOMING);
		if (toValue == null) {
			return false;
		}
		return isPartOfPreview(new Value(toValue.getStartNode()));
	}

}
